docs(spec): ApprovalDecisionResult records what the stranded-decision throw carries - #16983
Conversation
… throw carries The contract already declared the posture: a decision that finalises a flow-bound request and cannot resume its own run throws rather than answering `resumed: false`, because a recorded decision whose flow never advances is #4420's zombie half-state. What it never said is that the throw is now readable. The maintainer ruling #13807 (2026-09-04, decision batch #37) owes both halves — "throws with the decision and run identified; the fields are the published way to read it" — and only the first half was written down. The `resumed` docblock now names the second: the 500-class `RESUME_FAILED` carries `finalized`, `decision`, `runId` and `repairable` on its ERROR body as `StrandedDecisionDetails` (`@objectstack/types`, attached by `strandedDecisionFailure`, read back by `strandedDecisionDetails`, merged into the response by the REST approvals door), and the status code does not move because a durable decision over a run that will not advance is still a failure. Prose only. No member is added: the four facts ride the ERROR, so putting them on this success shape would declare a shape that never carries them, and the docblock says so explicitly. It also keeps them distinct from `resumeFailure`, which reports the #16472 family's different event — a resume failure told behind an answer that still succeeded. The changeset is owed on the second half of the two-part test, not the first: `src/contracts/approval-service.ts` is outside `packages/spec`'s `files[]`, but the changed docblock text is emitted into `dist/contracts/index.d.ts` and `index.d.mts`, which `files[]` publishes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 131 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5941baef8cefa735de22934c9568cb676d2dbff0 && git checkout 5941baef8cefa735de22934c9568cb676d2dbff0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin eda26cef07771f812c8091f97d935bc14900eb77 0257092344564d16e642e94cc19c9381c8edddd2 && git checkout -B drift-repro eda26cef07771f812c8091f97d935bc14900eb77 && git merge --no-ff 0257092344564d16e642e94cc19c9381c8edddd2
node scripts/docs-audit/affected-docs.mjs --json eda26cef07771f812c8091f97d935bc14900eb77 |
Fixes #15439
Clause-②: no
No accept set moves, no member is added or removed, no export changes. The deliverable is
docblock prose recording a posture the already-merged services half implements — pulling the
contract back to what is declared, which is the ordinary lane.
The falsification test came first, and the premise held — one half of the ruling, not both
The card and its unlock scan both warned that this might be zero work: the
resumeddocblocklooked like it already recorded the ruling's item 2. Measured on
origin/main50dc2141a,locating
ApprovalDecisionResultby symbol, never by the card's rotted:622anchor, itrecords one half of item 2 and not the other.
The standard, verbatim from the #13807 ruling (maintainer 2026-09-04, director batch #37):
Half A — "throws … never a half-state" — was already discharged. The
resumeddocblock onorigin/main, verbatim:Half B — "with the decision and run identified; the fields are the published way to read it" —
was not. Nothing anywhere in
ApprovalDecisionResultsaid what the throw carries. Scanning thewhole 927-line contract file rather than the interface alone,
finalizedappears in a docblockexactly three times outside the member declarations, and all three are inside the
ResumeFailureReportdocblock — a different symbol, where the mention is a⛔ Not …exclusion note drawing a boundary away from
StrandedDecisionDetails, not a record of what thedecidedoor's own throw publishes. The ruling names theApprovalDecisionResultdocblock.not settle this:
resumeError/resumeFailureare "the published way to read" a resumefailure — but of the other event. Those two carry the #16472 family's failure behind an
answer that still succeeded. The stranded decision does not answer at all; it throws, and its four
facts ride the ERROR body. Reading the first as discharging the second is the conflation this PR's
new paragraph closes explicitly.
What this changes — prose only
One paragraph appended to the
resumeddocblock ofApprovalDecisionResult, plus a changeset.No member added, no member changed, no export moved.
⛔ The card's loudest fence is honoured and then written into the file: the four facts are not
added to
ApprovalDecisionResult. The new text says so in as many words, so the next reader whonotices the asymmetry finds the reason rather than repeating the mistake.
Every citation in the new prose is a symbol —
StrandedDecisionDetails,strandedDecisionFailure,strandedDecisionDetails,RESUME_FAILED— and nopath:NNNlinereference is introduced.
git diff -U0 | grep -P '^\+.*\.ts:\d+'matches nothing.The sentence is true — read in the merged code, not inferred from the PR title
Each claim measured on
origin/main50dc2141a:ApprovalService's privateresumeRecordedOutcome(
packages/plugins/plugin-approvals/src/approval-service.ts) ends its catch arm withthrow strandedDecisionFailure(…, { finalized: true, decision, runId, repairable }).repairableis the engine's discriminator, not the message text. The same arm computesconst repairable = status === 'stranded';fromApprovalService.resumeStatusOf(err), with thecomment "the engine's own discriminator decides
repairable, never this door and never themessage text".
handleApprovalErrorinpackages/rest/src/rest-server.tscallsstrandedDecisionDetails(err)and spreads the result into the response object undercode === 'RESUME_FAILED'— top-level members of the error body, presence-gated, neversynthesised.
/^RESUME_FAILED/to500.RESUME_TARGET_LOST(409) is a different exit: it is raised byassertRunResumablebefore any write, so it is not a stranded decision and the new prosedoes not claim it.
StrandedDecisionDetailsis unchanged by this PR — it lives inpackages/types/src/stranded-decision.tsand this PR does not touch that package.Sequencing verified rather than assumed, as
Blocked-by: #13807demanded: #13807 is closed andPR #15436 is merged, so the sentence describes fields that exist on
maintoday. Writing itearlier would have put a false statement into the contract.
Changeset — decided on BOTH halves, with controls that discriminate
Half 1 alone gives the wrong answer here.
packages/spec'sfiles[]is['dist','json-schema','liveness','prompts','llms.txt','README.md','src/**/*.zod.ts','CHANGELOG.md','api-surface','spec-changes.json'],and
src/contracts/approval-service.tsis not a*.zod.ts⇒ half 1 says "not published".Half 2 decides it. After
pnpm --filter @objectstack/spec build, grepping the built tree:What that throw carries is publisheddist/contracts/index.d.ts,dist/contracts/index.d.mtszombie half-state of #4420dist*.test.ts)the retired predicate is gone from the contract filesrc, absent fromdist⇒ the grep is not matching everythingdistis infiles[], so the changed text ships. ⇒ apatchchangeset for@objectstack/specis owed and is included. (The text reaches no otherfiles[]entry —json-schema,liveness,prompts,api-surface,llms.txtandspec-changes.jsonallanswer zero.)
Checks — by name, exit codes captured before any pipe
All at the final commit
025709234.pnpm lint(repo-wide,eslint . --no-inline-config)pnpm --filter @objectstack/spec testpnpm --filter @objectstack/spec typecheckpnpm --filter @objectstack/spec check:test-typecheckpnpm --filter @objectstack/spec buildpnpm --filter @objectstack/spec check:generatedresume-failure-report.pin.test.ts(the pin that reads this contract's source text)scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack)The pin test matters here because it reads
approval-service.tsas text. Its three textualinvariants were re-measured after the edit and still hold: the retired predicate is still absent
from the file,
resumeError?: string;still appears 4 times andresumeFailure?: ResumeFailureReport;twice, and bothresumeFailuredocblocks still carry theabsence sentence.
⊘ NOT MEASURED, declared to CI:
pnpm check:dual-build-cjs-loadsexits 3 —PREREQUISITE NOT MET, its own distinct code for "nothing was measured", because it reads builtoutput for ~55 packages that a targeted build does not produce. Clearing it is a whole-repo
pnpm build, which is CI's run, not a per-card one; a docblock inside a.tsfile emits no CJS orESM entry point, so this diff cannot move what that gate reads. Two other gates first exited 3 for
the same reason and were then measured green after building their named prerequisites
(
@objectstack/formula,@objectstack/lint,@objectstack/objectql):check:doc-formula-expressionsandcheck:lean-entry-closure— so 3 is read as NOT MEASUREDthroughout, never as a pass and never as a finding.
Out of scope, noted and deliberately not filed
The new paragraph is prose that nothing pins.
resume-failure-report.pin.test.tsstates inits own header that anything about
StrandedDecisionDetailsis "⛔ Not pinned, deliberately" —that exclusion belongs to the #16472 family it gates, and it means this sentence can rot the way
the docblock line references this lane repointed today did. A pin (the natural shape: assert the
resumeddocblock names all four facts, with the producer's four keys read from@objectstack/typesrather than re-spelled) is a new test file, and this card's scope is thedocblock plus one changeset. Left to the PM to judge rather than filed by this seat, and the
carrying PR is this one.
Generated by Claude Code